昨天把模型搬到 Cloudflare R2,網站部署好之後,今天用手機實際玩了一次。
測試環境:
| 項目 | 結果 |
|---|---|
| 旋轉(單指拖曳) | 可以 |
| 平移(雙指一起拖曳) | 可以,但蠻難移 |
| HUD(計時/彩蛋數字) | 有顯示 |
| 輕點 | 沒有反應,計時也沒有啟動 |
遊戲的玩法是「點到彩蛋就算找到」,輕點沒反應,在手機上就完全不能玩。今天就來處理這個問題:先找出原因,再動手修。
原本 main.js 處理點擊的程式,開頭是這樣:
window.addEventListener('click', (event) => {
if (isFinished()) return;
if (event.target !== renderer.domElement) return;
startTimer();
// ……接下來才是射線判斷,看有沒有點到彩蛋
window.addEventListener('click', ...):在整個視窗上監聽 click,點到任何地方都會進來。isFinished():遊戲是不是已經結束(彩蛋全部找完)。剛開始玩的時候一定是 false,所以不會在這裡停下來。event.target !== renderer.domElement:event.target 是被點到的元素,renderer.domElement 是 Three.js 畫圖用的 <canvas>(畫布)。點到的不是畫布,例如點到「重新開始」按鈕,就不處理。startTimer():第一次點擊時開始計時。startTimer() 後面。手機輕點時計時沒有啟動,代表程式根本沒有走到 startTimer()。所以問題不是「彩蛋太小、沒點中」,因為那是射線判斷的事,排在更後面。只剩兩種可能:
click 根本沒有觸發。click 有觸發,但被點到的不是畫布,代表有別的元素蓋在畫布上面。為了分清楚是哪一種,我在 main.js 最後暫時加上這段診斷程式:
// 暫時的診斷程式,測完要刪掉
['pointerdown', 'pointerup', 'click'].forEach((type) => {
window.addEventListener(type, (event) => {
showToast(`${type}:${event.target.tagName}`);
}, true);
});
['pointerdown', 'pointerup', 'click']:要觀察的三種事件。pointerdown 是手指(或滑鼠)按下,pointerup 是放開,click 是原本程式在等的點擊。前兩種屬於 Pointer Events。.forEach((type) => { ... }):對三種事件各做一次監聽。window.addEventListener(type, ..., true):第三個參數 true 代表在「捕獲階段」監聽。依照 MDN 的說明,這樣的監聽器會在事件傳到底下任何元素之前就先收到。掛在 window 上,不管點到哪個元素都看得到。event.target.tagName:被點到的元素的標籤名稱,畫布會顯示 CANVAS。showToast(...):之前在 ui.js 寫好的提示訊息,把「事件名稱:元素標籤」顯示在畫面上。手機上不方便開 Console,用畫面提示最直接。用手機輕點畫布,畫面上出現 pointerdown:CANVAS 和 pointerup:CANVAS,沒有出現 click。
所以:
click。遊戲用 TrackballControls 處理旋轉、平移、縮放,它也在監聽畫布上的事件。我去讀了 three.js r160 的 TrackballControls 原始碼,跟觸控有關的是這幾件事:
style.touchAction 設成 'none',原始碼註解寫的用途是關掉觸控捲動。pointerdown 時,如果這是第一根按下的手指,會對畫布呼叫 setPointerCapture,讓這根手指之後的事件都送到畫布。wheel(滾輪)和 contextmenu(右鍵選單)會呼叫 preventDefault(),pointerdown、pointerup 都沒有。從原始碼看不出 TrackballControls 有擋掉 click。手機瀏覽器為什麼沒有送出 click,這次沒有再往下追。既然實驗已經確定 pointerdown 和 pointerup 都收得到,就直接用這兩個事件,自己判斷什麼叫「輕點」。
在畫布上,手指會做三種事:
| 動作 | 特徵 |
|---|---|
| 輕點 | 一根手指按下又放開,中間幾乎沒有移動 |
| 旋轉 | 一根手指按下後拖曳,移動距離比較長 |
| 平移、縮放 | 兩根手指同時在畫布上 |
所以「輕點」的條件可以寫成:只有一根手指,而且按下到放開移動不多。
這段判斷跟遊戲規則無關,任何畫布都用得到。專案裡每個檔案各管一件事(loader.js 載入模型、scene.js 場景、timer.js 計時、ui.js 畫面提示),所以輕點判斷也獨立成一個新檔案。
src/tap.js ★// ★ 判斷「輕點」:按下到放開,同一根手指(或滑鼠左鍵)移動不多才算
const TAP_MAX_MOVE = 10; // ★ 移動超過 10 像素就當成拖曳
export function onTap(element, callback) { // ★
let tapStart = null; // ★ 按下的位置與指標編號;null 代表這次不算輕點
let activePointers = 0; // ★ 目前有幾根手指(或滑鼠)按在 element 上
element.addEventListener('pointerdown', (event) => {
activePointers++;
if (activePointers === 1 && event.button === 0) {
tapStart = { x: event.clientX, y: event.clientY, id: event.pointerId };
} else {
tapStart = null; // 第二根手指放上來(雙指操作)或不是左鍵,不算輕點
}
});
element.addEventListener('pointerup', (event) => {
activePointers = Math.max(0, activePointers - 1);
if (!tapStart || event.pointerId !== tapStart.id) return;
const dx = event.clientX - tapStart.x;
const dy = event.clientY - tapStart.y;
tapStart = null;
if (Math.hypot(dx, dy) > TAP_MAX_MOVE) return; // 移動太多,是拖曳
callback(event);
});
element.addEventListener('pointercancel', () => {
activePointers = Math.max(0, activePointers - 1);
tapStart = null; // 瀏覽器中斷了這次觸控,不算輕點
});
}
TAP_MAX_MOVE = 10:按下到放開,移動超過 10 像素就當成拖曳,不算輕點。10 是我自己選的起始值,不是什麼標準:如果常常點了沒反應,可以調大;如果旋轉時常被誤判成點擊,就調小。onTap(element, callback)export function onTap(element, callback):對外提供的函式。element 是要監聽的元素,callback 是判定為輕點時要執行的函式。用法跟 addEventListener 很像,呼叫的人不需要知道輕點是怎麼判斷的。tapStart:記錄按下時的位置和指標編號。null 代表「這次不算輕點」。activePointers:目前有幾根手指(或滑鼠)按在元素上,用來分辨單指和雙指。onTap 裡面,每呼叫一次 onTap 就各有一份,不會互相干擾。pointerdown:按下時activePointers++:多一根手指按下。activePointers === 1:只有這一根手指,才可能是輕點。event.button === 0:button 是按下的是哪個鍵,0 是主要按鍵,通常是滑鼠左鍵。pointerdown 的事件物件是 PointerEvent,依照 MDN,它繼承了 MouseEvent 的屬性,所以也有 button。觸控時 button 的值 MDN 這頁沒有寫,不過改完後手機輕點有反應,代表在我的手機上,手指按下時這個條件成立。event.button === 0:button 表示是哪個按鍵造成這次事件。依照 MDN,0 是主要按鍵(通常是左鍵)、1 是中鍵或滾輪、2 是右鍵。pointerdown 的事件物件是 PointerEvent,它繼承了 MouseEvent 的屬性,所以也有 button。Pointer Events 規格的按鍵狀態表裡,手指觸碰螢幕、觸控筆碰到螢幕,button 也都是 0。所以這個條件同時涵蓋「滑鼠左鍵」和「手指」,並把右鍵(2,TrackballControls 用來平移)、中鍵(1)排除在外。
event.clientX、event.clientY:按下的位置,以瀏覽器視窗左上角為原點,單位是像素。event.pointerId:每個指標的唯一編號,兩根手指會有不同的編號。放開時用它確認是不是同一根手指。else 的 tapStart = null:第二根手指放上來(變成雙指操作),或按的是右鍵,就把這次作廢。pointerup:放開時activePointers = Math.max(0, activePointers - 1):少一根手指。Math.max(0, ...) 確保數字不會變成負的。if (!tapStart || event.pointerId !== tapStart.id) return:這次已經作廢,或放開的不是當初按下的那根手指,就不處理。dx、dy:放開時的位置減掉按下時的位置,是水平、垂直各移動了多少像素。tapStart = null:用完就清掉,下一次重新開始記錄。Math.hypot(dx, dy):回傳各參數平方和的平方根,也就是按下點到放開點的直線距離。超過 TAP_MAX_MOVE 就是拖曳,直接結束。callback(event):判定是輕點,執行傳進來的函式,並把 pointerup 的事件物件交給它。pointercancel:被中斷時pointercancel 是瀏覽器判斷這個指標不會再有後續事件時送出的,例如觸控途中切換 App、螢幕轉向、同時按下太多根手指。src/main.js最上面多一行 import:
import { onTap } from './tap.js'; // ★ 輕點判斷
點擊處理改用 onTap:
onTap(renderer.domElement, (event) => { // ★ 取代 window 的 click(手機收不到 click)
if (isFinished()) return;
// ★ 不再檢查 event.target:onTap 已經直接掛在畫布上
startTimer(); // ★ 第一次點擊才開始計時(之後再呼叫會直接跳過)
const pointer = new THREE.Vector2();
const raycaster = new THREE.Raycaster();
// 1. 把滑鼠的像素座標,換算成 NDC
pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;
// 2. 從相機、往滑鼠指的方向,射出這條線
raycaster.setFromCamera(pointer, camera);
// 3. 問這條線,打中了 scene 裡的哪些東西(true 代表連子物件也要檢查)
const intersects = raycaster.intersectObjects(scene.children, true);
if (intersects.length > 0) {
const hit = intersects[0].object;
const eggRoot = findEggRoot(hit);
if (!eggRoot) {
//console.log('點到公園本體:', hit.name);
return;
}
if (eggRoot.userData.found) {
console.log('這個彩蛋已經找過了');
return
}
const result = markEggFound();
if (!result) return;
eggRoot.userData.found = true;
if (result.allFound) {
showResult(result.seconds)
} else {
showToast(`🥚 已找到 ${result.foundEggs} / ${result.totalEggs}`);
}
}
});
這次只改了開頭兩行,其餘跟原本完全一樣:
onTap(renderer.domElement, (event) => { ... }):取代原本的 window.addEventListener('click', ...)。監聽的對象從整個視窗改成畫布,判定是輕點才執行裡面的程式。if (event.target !== renderer.domElement) return;:onTap 直接掛在畫布上,進來的一定是畫布上的輕點,不需要再檢查。event.clientX、event.clientY 不用改:傳進來的是 pointerup 的 PointerEvent,它繼承自 MouseEvent,一樣有這兩個屬性。改完記得把第二節的診斷程式刪掉。
電腦版的滑鼠點擊也改由 onTap 判斷。按下到放開移動超過 10 像素,就算拖曳,不算點擊。
改完後再用手機測,輕點已經有反應了。電腦版也有測試,沒有因為從監聽事件 click 改成 pointerup/pointerdown 而有不預期的 bug。
手機可以玩了,但實際玩起來還有一個問題:手機螢幕小,就算把模型放到最大,還是很難看出彩蛋在哪裡,放大的效果有限。 另外,上面測試表裡的雙指平移也蠻難移。這兩點先記下來,目前接近尾聲。明天打算做一個總整理,列出未來還可以優化的地方,以及我這一系列文的回顧。